Repository navigation
feat(release): signed key list, yank list and key expiry (#226, part 1) - #235
Merged
Merged
Conversation
…ate (#226) RFC-0046 §9: bundle-sign refuses when the active key's notAfter is under 90 days away, so the next key is announced and pinned in time. - release.yml bundle-sign: PINNED_NOT_AFTER next to the pinned key id and public key; the check runs before the key is read, in dry runs too. - docs/security/release-signing-keys.md: a "Not after" column (cloudhal-release-2026a: 2027-11-12, pending the owner's decision), what the date means for consumers, and rotation starting 120 days ahead. - Tests: the inline script refuses at 89 and -1 days and signs at 91; the doc's not-after date equals the workflow's pin. Refs #226 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Madraka <[email protected]>
The #226 decisions, recorded in RFC-0046 §9 and built here: - Contracts: release-keys (keys.json, root-signed, expires at most 13 months after issuedAt) and release-yank-list (yanked.json, signed by a yank-role key, expires exactly 30 days after issuedAt), with fixtures for TS and Rust. - @cloudhal/release-verify: - verifyKeys and verifyYanked check signatures and freshness, and a stale yank list is refused with stale-yank-list; - mergeTrust only reduces trust in the pinned keys (revoke, retiring, a narrower window) and never adds a key; - verifyRelease enforces each key's window (key-expired; after notAfter a key verifies only while a fresh keys.json lists it as retiring) and refuses yanked versions (yanked); - PINNED_KEYS carries each key's window, and PINNED_ROOTS is empty until the owner's root ceremony. - bundle-sign refuses when fewer than 90 days remain before notAfter, and refuses a statement created outside the key's window. - scripts/release-keys.ts (built-ins only): root-init (passphrase- encrypted root), yank-init, sign-keys and sign-yanked (append-only against the published list). No command prints a private key. - security/: keys.source.json, yank-list.json (append-only) and a README. - The runbook in docs/security/release-signing-keys.md: first-time setup, rotation, emergency revocation and yanking. KMS is deferred to phase 3. Refs #226 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Madraka <[email protected]>
A yank list now carries a signed `serial` that increases by one at every signing (sign-yanked numbers it one above the published list's). verifyYanked takes `lastSerial`: a consumer that keeps state (the CLI) refuses a lower serial, a rollback to an older, validly signed list, with stale-yank-list. RFC-0046 §9 and the runbook note that a stateless consumer cannot detect a rollback inside the 30 days, and that the yank-list branch is append-only under a ruleset. Refs #226 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Madraka <[email protected]>
- Append-only on the consumer: YankMemory (yank key id, highest serial,
every yanked version and revoked key id) plus rememberYanks.
verifyYanked({ memory }) refuses a list from the same yank key that
drops a remembered entry or goes back in serial (stale-yank-list). A
list from another root-listed yank key starts a new memory (rotation
and pruning). RFC §9, the runbook and the README now say what a stolen
yank key can do for stateless consumers (undo yanks until the root
rotates it), and emergency revocations go into a root-signed keys.json
within days. The "deny service only" claim is corrected everywhere.
- sign-keys and sign-yanked run the consumer's verifiers over the exact
signed bytes before writing (schema, unique ids, windows, timestamps,
the 256 KiB limit, the published list as memory), and nothing is
written on failure. sign-yanked verifies keys.json with the pinned
root (--root-pub before pinning). A CI test validates
security/keys.source.json and security/yank-list.json and keeps the
release keys equal to PINNED_KEYS and bundle-sign's window.
- addMonths clamps to the last day of the target month (tested on
01-31 + 13 months).
- The bundle-sign/doc test follows the key bundle-sign uses, so a
successor can be announced first.
- RFC §11b: online, never fall back to a snapshot; deploy --release also
requires yanked.json; --from takes both lists. Open questions §11c 5
and 7 and the main list's 2 are marked resolved.
- The key doc's by-hand verification now uses @cloudhal/release-verify
(window, expiry, yanks), and the ceremony order commits keys.json
before the first yank list, with the workflow steps marked as
arriving with the second PR.
- publish-ci verifyAsConsumer and the signed self-check use the
PINNED_KEYS record (with its window), and accept a yank list.
Refs #226
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
Madraka
added a commit
that referenced
this pull request
Nov 14, 2026
- yank-list.yml installs exactly @cloudhal/release-verify's pinned dependencies, with --ignore-scripts and before the key reaches any step, so sign-yanked verifies the exact bytes it signs (keys.json against the pinned root, the published list as memory). The test allows only that install. - trustAssets returns the verified yank list too. publish-ci now runs the consumer check of the bundle after it, with that list, so a yanked version or a key the list revokes is refused before anything is uploaded. - The trust-asset tests use the async signers. Refs #226 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Madraka <[email protected]>
… key (#226) - YankMemory also keeps the newest keys.json issuedAt and every yank key a root list revoked or that a newer key replaced (new module yank-memory.ts). verifyKeys and verifyYanked refuse an older keys.json, a retired yank key, and another yank key unless a newer keys.json replaces the remembered one. - verifyKeys (and so sign-keys) refuses a keys.json with two active yank keys. - RFC-0046 §9, the runbook, the README and the changeset say so. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Madraka <[email protected]>
Madraka
added a commit
that referenced
this pull request
Nov 14, 2026
- yank-list.yml installs exactly @cloudhal/release-verify's pinned dependencies, with --ignore-scripts and before the key reaches any step, so sign-yanked verifies the exact bytes it signs (keys.json against the pinned root, the published list as memory). The test allows only that install. - trustAssets returns the verified yank list too. publish-ci now runs the consumer check of the bundle after it, with that list, so a yanked version or a key the list revokes is refused before anything is uploaded. - The trust-asset tests use the async signers. Refs #226 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Madraka <[email protected]>
sign-yanked's self-check gave the published list's memory the current keys.json issuedAt, so a list from the new yank key under that same keys.json was refused as stale. When the published list has another signer, the memory names no keys.json (the epoch), as for a consumer that kept the earlier one; every published entry is still carried (checkAppendOnly) and the serial continues. An end-to-end rotation test covers publish under 2026a, a root list revoking it, signing under 2026b, a CLI memory moving from 2026a/T1 to 2026b/T2, and the old list refused afterwards. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Madraka <[email protected]>
Madraka
force-pushed
the
claude/release-trust-226
branch
from
November 14, 2026 15:29
a24a277 to
9e6b5d1
Compare
Madraka
added a commit
that referenced
this pull request
Nov 14, 2026
- yank-list.yml installs exactly @cloudhal/release-verify's pinned dependencies, with --ignore-scripts and before the key reaches any step, so sign-yanked verifies the exact bytes it signs (keys.json against the pinned root, the published list as memory). The test allows only that install. - trustAssets returns the verified yank list too. publish-ci now runs the consumer check of the bundle after it, with that list, so a yanked version or a key the list revokes is refused before anything is uploaded. - The trust-asset tests use the async signers. Refs #226 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Madraka <[email protected]>
…ify (#226) The key doc's by-hand script now uses @cloudhal/release-verify (#235 round 1), so the core-contracts doc tests assert what it does: the request binds the version and the channel (now passed explicitly), both signed lists are verified and applied (mergeTrust, yanked), no fallback to an old copy, every tar entry is read, and the two GitHub checks remain. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Madraka <[email protected]>
Madraka
added a commit
that referenced
this pull request
Nov 14, 2026
- yank-list.yml installs exactly @cloudhal/release-verify's pinned dependencies, with --ignore-scripts and before the key reaches any step, so sign-yanked verifies the exact bytes it signs (keys.json against the pinned root, the published list as memory). The test allows only that install. - trustAssets returns the verified yank list too. publish-ci now runs the consumer check of the bundle after it, with that list, so a yanked version or a key the list revokes is refused before anything is uploaded. - The trust-asset tests use the async signers. Refs #226 Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Madraka <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Owner ceremony (in order; nothing below has been done, and no key exists yet)
The full commands are in
docs/security/release-signing-keys.md› First-time setup.node scripts/release-keys.ts root-init --key-id cloudhal-root-2026a --out <dir>. It asks for a passphrase twice and writes the private key only encrypted. Seal two copies of that file and its passphrase, in separate places. Then add the root's public key to the Root keys table and toPINNED_ROOTS(packages/release-verify/src/trust.ts) in one PR; a test keeps the two equal.node scripts/release-keys.ts yank-init --key-id cloudhal-yank-2026a --out <dir>. It writescloudhal-yank-2026a.pem, mode 0600, and never prints it.release-yank, with deployment branchesmainonly and no required reviewer. Then rungh secret set RELEASE_YANK_KEY --env release-yank < cloudhal-yank-2026a.pemand move the.pemfile offline.security/keys.source.json.node scripts/release-keys.ts sign-keys --root-key <dir>/cloudhal-root-2026a.key.enc --root-pub <dir>/cloudhal-root-2026a.pub.json. It writes and verifiessecurity/keys.jsonand.sig.yanked.jsonis signed by the Yank list workflow's first run (--first-run). That workflow comes in the second Release signing phase 2: signed keys.json and yanked.json, key expiry, KMS custody (RFC-0046 §9) #226 PR.security/keys.json, its.sigand the updated source in a reviewed PR, then deploy cloudhal.dev. The serving and the weekly workflow come in the second Release signing phase 2: signed keys.json and yanked.json, key expiry, KMS custody (RFC-0046 §9) #226 PR (see Not in this PR).What
This is the first of two #226 PRs. It implements the decisions on #226, recorded in RFC-0046 §9.
RFC-0046 §9 amended with every decision:
security/;keys.json;release-yankenvironment. It can only reduce trust, and a stolen yank key at worst denies service, with recovery through a root rotation;keys.jsonlasts at most 13 months, andyanked.jsonexactly 30 days, re-signed weekly. A stale list blocks onlydeploy --release;--fromrequires both lists, unexpired;Contracts. Two new roots in core-contracts:
release-keys(cloudhal.release-keys@1) andrelease-yank-list(cloudhal.release-yank-list@1). Each has valid and invalid fixtures for both languages. The TypeScript is generated and exported in the index; the Rust getsReleaseKeysandReleaseYankListwith fixture round trips.@cloudhal/release-verify:verifyKeys: a pinned root (PINNED_ROOTS, empty until the ceremony), size limits, the schema, unique key ids, non-empty windows,issuedAt≤ now (5 min skew) <expires≤issuedAt+ 13 months.verifyYanked: signed by an active yank-role key that the verifiedkeys.jsonlists and that is valid now.expiresmust be exactlyissuedAt+ 30 days, and a stale list givesstale-yank-list.mergeTrust(reduce-only): a listing with another public key or role revokes the pin; otherwise the stricter status wins and the window narrows to the intersection;listedis set only by a list verified now.revokedKeysrevokes. Keys a list names but the consumer doesn't pin are never added.verifyReleaserefuses a statement whosecreatedAtis outside the key's window (key-expired), and a key pastnotAfterunless a fresh list marks it retiring (key-expired). It also refuses a yanked version (yanked).key-expired,yanked,key-list-invalid,key-list-expired,yank-list-invalid,stale-yank-list.PINNED_KEYSnow carries the window, and a test keeps it equal to the key doc's Since and Not after columns.bundle-sign(release.yml):notAfter(2027-11-12), dry runs included;createdAtis outside[PINNED_NOT_BEFORE, PINNED_NOT_AFTER].A test keeps both dates equal to the key doc.
scripts/release-keys.ts(Node built-ins only), with four commands:root-init: the private key is written only passphrase-encrypted (PKCS#8 with AES-256), with mode 0600. It refuses to overwrite a key, and the public key goes to<id>.pub.json.yank-init: the PEM is written with mode 0600 for the secret.sign-keys: the root must open with the passphrase and match--root-pub; validity is 1 to 13 months (default 12); the output is self-verified.sign-yanked: the key comes fromRELEASE_YANK_KEYor--key-file, and the variable is deleted after it is read. The key must bekeys.json's active yank key and inside its window. The list is append-only against the published one, which--publishedor--first-runmust provide.expiresis exactly 30 days afterissuedAt.No command prints a private key, and a test checks that.
Sources under
security/:keys.source.json(withcloudhal-release-2026a; the yank key is added at the ceremony),yank-list.json(empty, append-only) and a README.Runbook (
docs/security/release-signing-keys.md):Changeset for
@cloudhal/release-verifyand@cloudhal/core-contracts.Update: serial against rollbacks (4ff6937)
yanked.jsonnow carries a signedserial, one higher at every signing;sign-yankednumbers each list from the published one.verifyYanked({ …, lastSerial })refuses a lower serial withstale-yank-list. Such a lower serial is a rollback to an older, validly signed list.deploy --release.yank-listbranch is append-only under a ruleset.Review round 1 (bb70e90)
YankMemoryholds the yank key id, the highest serial, and every yanked version and revoked key id;rememberYanksproduces it.verifyYanked({ memory })refuses a list from the same yank key that goes back in serial or drops a remembered entry (stale-yank-list).keys.jsonwithin days. The "deny service only" claim is corrected.sign-keysandsign-yankedrun the consumer's verifiers over the exact signed bytes before writing: the schema, unique ids, windows, timestamps, the 256 KiB limit, and the published list as memory. Nothing is written on failure.sign-yankedverifieskeys.jsonwith the pinned root (--root-pubonly before pinning).security/keys.source.jsonandsecurity/yank-list.json, and keeps their release keys equal toPINNED_KEYSandbundle-sign's window.addMonthsclamps to the last day of the target month (tested on 2027-01-31 + 13 months).bundle-signuses, so a successor row can be announced early.yanked.jsonis required too;--fromtakes both lists. §11c points 5 and 7 and the main list's question 2 are resolved.@cloudhal/release-verify(window, expiry, yanks). The ceremony commitskeys.jsonbefore the first yank list, and marks the steps that arrive with feat(release): publish the yank list weekly, serve both lists, attach them to releases (#226, part 2) #237.publish-ciand the signed self-check use thePINNED_KEYSrecord (with its window) and accept a yank list.release-keys13,release-workflows14,release-bundle-sign5,release-bundle-verify12,release-bundle-publish9,release-bundle18; biome and size clean.Merge gate (6902752)
Replayed
keys.json.YankMemory(now inyank-memory.ts) also keeps:keys.jsonissuedAt;verifyKeys({ memory })andverifyYanked({ memory })refuse:keys.json(key-list-invalid);yank-list-invalid);keys.jsonlists it and no longer lists the old one as active (stale-yank-list).rememberYanks(yanked, keys, memory)carries the memory forward. Tests cover each case, including the attack: the previouskeys.jsonplus an empty list from the old key.One active yank key.
verifyKeys, and sosign-keys, refuses two. Rotation replaces the key and never overlaps; the runbook and RFC §9 say so.Tests: release-verify 84 passed, 1 skipped. cli:
release-keys13, workflows 14, sign 5, verify 12, publish 9. Biome and size are clean.Not in this PR (the second #226 PR)
release-yank, actions pinned by SHA, least permissions), and serving/.well-known/cloudhal/from cloudhal.dev.yanked.jsonto an append-onlyyank-listbranch, and the cloudhal.dev Worker serves that branch's raw file.KMS
KMS custody is deferred to phase 3. Follow-up: #234. Signing stays in the
release-signingenvironment with a required reviewer. #226's done-when doesn't need KMS: revocation and expiry, the real gaps, are closed here.Tests (run locally, one at a time)
packages/release-verify: 77 passed, 1 skipped (the opt-in real tarball). That includes the newtrust.test.ts(14), which covers:expires, and staleness;packages/cli:release-keys.test.ts: 8 passed (file modes, encrypted root, no secret printed, refusals, append-only, the CLI path throughRELEASE_YANK_KEY, and outputs checked by the consumer's verifier);release-bundle-sign: 5 passed (including the newcreatedAtwindow);release-workflows: 14 passed (both pinned dates equal to the doc).packages/core-contracts: 612 passed, with the new fixtures.cargo test -p cloudhal-schemapasses 23 fixture tests (2 of them new), andcargo clippy -p cloudhal-schema --all-targets -D warningsandcargo fmt --checkare clean.pnpm genis clean, and typechecks, biome andsize:checkare ok.pnpm driftis clean after the commit.Refs #226 (the second PR closes it)
🤖 Generated with Claude Code